fix(daemon): isolate disconnect cancellation and resource teardown#1225
Conversation
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
|
P1: request-scoped cancellation still does not cover runner startup/build, so removing the global fallback can leave the exact long-running prep process it previously killed. |
|
Re-review at
CI is green, but this head is not ready. |
3f0f7c6 to
9647092
Compare
|
@thymikee both blockers addressed at 1. Request cancellation now covers blocked runner startup/build/launch, no global abort. 2. Targeted platform-close error is preserved. The Rebased onto latest |
| const { req, session, logPath, attemptCleanup } = params; | ||
| if (!shouldDispatchPlatformClose(req, session)) return undefined; | ||
| if (shouldStopAppleRunnerBeforeTargetedClose(session)) { | ||
| await attemptCleanup('apple_runner_pre_close', () => stopAppleRunnerForClose(session)); |
There was a problem hiding this comment.
P2 (dependency concern): making the pre-close runner stop best-effort changes non-simulator close semantics — a failed pre-close stop now proceeds with the platform close.
On origin/main this same stopAppleRunnerForClose call was an un-wrapped await inside the close block, so a throw skipped dispatchCommand('close') entirely (propagated out via the try/finally, close never dispatched). Wrapping it in attemptCleanup swallows the error into cleanupFailures and falls straight through to the try { await dispatchCommand(... 'close' ...) } below.
The stop is intentionally ordered before dispatch "to avoid runner/app races" (per the comment in stopOrRetainAppleRunnerAfterClose and shouldStopAppleRunnerBeforeTargetedClose = apple && !iosSimulator, i.e. real device / macOS). So a failed pre-close stop now dispatches close while the runner may still be alive — the exact race the ordering guards against. The post-close stopOrRetainAppleRunnerAfterClose re-attempt only mitigates after the racy close.
Also note: if the pre-close stop fails but the close then succeeds, platformCloseError is undefined, so Closed is recorded and the cleanup aggregate (containing apple_runner_pre_close) is thrown — a Closed-and-error result.
Suggest either keeping the pre-close stop error-propagating for non-simulator targets (skip the close as before, letting the close error path own it), or, if proceeding is intentional, documenting why it's safe and adding a non-simulator regression. No test covers this path — the Blocker-2 test uses kind: 'simulator', so shouldStopAppleRunnerBeforeTargetedClose is false and the pre-close branch never runs.
There was a problem hiding this comment.
Fixed in 4d729f0. The pre-close runner stop for non-simulator Apple targets is no longer best-effort. dispatchTargetedPlatformClose now awaits stopAppleRunnerForClose(session) directly and, on failure, returns that error and skips the close dispatch entirely — preserving the original code/details/hint and never recording Closed. Later independent cleanup (stopOrRetainAppleRunnerAfterClose, runtime hints, materialized paths) still runs, and lease release + session delete are unchanged. This also resolves the Closed-and-error case you flagged: a failed pre-close stop now sets platformCloseError, so Closed is never recorded and the original pre-close error is what's rethrown.
Added a real-device-shaped regression in session-close-shutdown.test.ts (kind: 'device', so shouldStopAppleRunnerBeforeTargetedClose is true): pre-close stop rejects → dispatchCommand is never called, the original AppError code/details/hint are preserved, no Closed action is recorded, later cleanup still re-attempts the runner stop, and the session is still deleted.
| }): Promise<PrepareAttemptResult> { | ||
| const { device, session, command, options, signal, attempt, error } = params; | ||
| const appErr = asAppError(error, 'COMMAND_FAILED'); | ||
| if (isRequestCanceledError(appErr)) { |
There was a problem hiding this comment.
P2 (Blocker 1 — proof gap, not a correctness bug): the runtime wiring is correct by code, but the new runner-client.test.ts test proves only the artifact-level signal threading and leaves the parts the blocker actually asks to prove untested.
What I verified by reading (all correct):
- disconnect →
markRequestCanceled(requestId)aborts the controller registered byregisterRequestAbortat request start (http-server / transport); requestIdflows meta → dispatch ctx (interactions.ts:78) → runner options →startRunnerSessionWithLeasegetRequestSignal(options.requestId);- signal →
ensureXctestrunArtifact→buildRunnerXctestrun→runCmdStreaming({signal})and → launchrunCmdBackground({signal}), bothkillProcessTreeon abort (watchCommandAbort); - retention: a build abort throws before
runnerSessions.set/writeRunnerLease(no session/lease); a launch abort (allowFailure:true) stores the session+lease, then the health check runs with the aborted signal, the transport mapssignal.aborted→createRequestCanceledError(), and this branch invalidates it viainvalidateRunnerSessionBestEffort(removes the stored session + releases the lease).
What no test exercises:
- the request-registry resolution —
getRequestSignal(options.requestId)instartRunnerSessionWithLease(the test passes a signal directly toensureXctestrunArtifact, bypassing the registry), so an actual disconnect's requestId→controller reaching startup is unproven; - the launch
runCmdBackground({signal})cancellation; - this session/lease-retention branch (
handlePrepareHealthFailurerequest-canceled → invalidate) — the PR's core "never retained" guarantee has zero coverage.
Suggest one test at prepareLocalIosRunner/ensureRunnerSession level driven through the registry: registerRequestAbort(reqA), start prep for reqA, abort mid-launch, assert reqA's session/lease is stopped/not retained while a concurrent reqB on a different device keeps its session. That would actually prove the blocker rather than the artifact seam.
There was a problem hiding this comment.
Fixed in 4d729f0. Added an integration-level test in runner-session.test.ts driven through the request registry rather than the artifact seam: prepareLocalIosRunner cancellation stops only the disconnected request and preserves unrelated concurrent prep.
It uses registerRequestAbort(reqA)/registerRequestAbort(reqB) and exercises the real prepareLocalIosRunner → ensureRunnerSession → startRunnerSessionWithLease path (no artifact-level mock). Request B prepares normally and its session is retained. Request A is canceled mid-startup: the launch stores the session + lease, then the health check runs with A's now-aborted signal and rejects with a request-canceled error, driving the handlePrepareHealthFailure request-canceled branch → invalidateRunnerSessionBestEffort (this branch). Assertions:
- A's
runCmdBackgroundlaunch received A's request signal, and that signal isaborted(a real launch would be killed via the process tree). getRunnerSessionSnapshot(deviceA)isnull— A's session/lease is not retained.getRunnerSessionSnapshot(deviceB)is still alive — the unrelated request survives.
|
Re-review at 4d729f0: the prior runner-retention and targeted-close blockers are fixed. One cancellation-propagation issue remains. In runner-artifact.ts, runCmdStreaming receives the request signal and aborts with reason request_canceled, but the catch at the build-for-testing boundary wraps every error as a new generic COMMAND_FAILED and nests the original details. isRequestCanceledError can no longer recognize it, so explicit cancellation during runner build is reported as an xcodebuild failure with a cache/build hint. Preserve the top-level request-canceled semantics before wrapping, and add a focused abort-during-build regression. The ready-for-human label is being removed pending that fix. No fixer dispatched. |
4d729f0 to
a9752f2
Compare
|
Fixed at |
|
Coordinator re-review at A normal runner command canceled during first startup can still strand session/lease ownership. Please make cancellation after launch request-scoped and ensure the just-created session/lease is never retained, then add a normal command regression that cancels during startup/launch and proves the canceled request leaves no session/lease while an unrelated request remains alive. Secondary isolation gap: concurrent requests that reuse the same supplied Also tighten the pre-header HTTP regression: its helper currently resolves immediately after
|
Cancel HTTP requests that lose their client before response headers, and scope disconnect cancellation to the affected request/device/session instead of a global Apple runner abort. Make session resource teardown failure-isolated so one rejected step no longer skips later cleanup, while preserving lease release and session deletion. Closes #1220 Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…se error preservation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…gate Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
… prep-cancellation coverage Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
a9752f2 to
2f2582e
Compare
|
Addressed the coordinator re-review from |
|
Coordinator re-review at One readiness evidence gap remains under the repository device-facing checklist: the new runner cancellation tests mock process launch/readiness, and the iOS smoke does not disconnect a real client during fresh XCTest startup. Please provide one practical simulator run that disconnects during fresh runner build/launch and shows the canceled runner process/session/lease is gone while an unrelated request remains usable. No code change is requested unless that run fails. |
|
Simulator evidence for the remaining readiness gap (PR head
All sessions and temporary daemon state were closed/cleaned. No code changes were needed. |
|
Agent-supervised re-record repair lifecycle, rebased onto #1225's failure-isolated close teardown. Consolidated from the earlier iterative rounds into the final teardown-commits model: - R7 keep-alive keyed off PERSISTED transaction state (repairSessionHeld signal), so a `replay --from` continuation without --save-script is still held on divergence. - Commit gated on transaction COMPLETION (saveScriptComplete/saveScriptCommitted), never on `close` alone — no prefix is ever published. - Single commit path: `commitRepairBeforeClose` runs before #1225's `runSessionCloseTeardown` destructive steps; a repair-armed session skips the teardown's ordinary writeSessionLog, non-repair keeps it. Idle-reap/shutdown commit-on-completion or tombstone via `finalizeRepairTeardown`. - BLOCKER fixes: reaped `replay --from` -> REPAIR_SESSION_EXPIRED; commit failures surfaced (not swallowed) and keep the session for retry (with healed-path reporting on success); race-safe atomic no-clobber publish; minimal `[open, close]` arms the transaction. Integrated with #1225: keeps runSessionCloseTeardown's failure-isolated cleanup + preserved platform-close error; repair commit happens first so a failed commit keeps the session addressable.
Summary
Scopes disconnect cancellation and teardown ownership to the affected request/session, addressing #1220 without changing public wire contracts or Apple runner retention policy.
INVALID_ARGSinstead of replacing another request's controller.request_cancelederror and guarantees a newly launched runner is disposed before persistence or invalidated while still unready, without affecting unrelated sessions.AppErrordetails, never records a failed close as successful, and retains required runner-stop-before-close ordering for physical Apple targets.Validation on current
main:check:affected --run(4,090 unit tests and 124 provider-integration tests), format, lint, typecheck, layering, Fallow, build, and focused cancellation suites.Link to Devin session: https://app.devin.ai/sessions/b1c3093bf2b248ca82a1a5df89152d02
Requested by: @thymikee